Skip to content

fix(session-group): 非文本种子开的群转发原消息并补上 AI 命名 - #1379

Open
deepcoldy wants to merge 1 commit into
masterfrom
fix/session-group-forward-seed
Open

deepcoldy wants to merge 1 commit into
masterfrom
fix/session-group-forward-seed

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

问题

私聊 p2pMode='group' 下,用合并转发的飞书消息集合(以及图片 / 文件等任何非文本消息)开的会话群有两个毛病:

  1. 群名永远停在占位名「新会话」,走不到自动改名(AI 命名)的逻辑里;
  2. 群里只有一条「📥 @某人 从私聊发起了本次会话:(非文本消息)」,看不出这个群是怎么来的——原始内容还留在私聊里。

根因

出生流程(maybeBirthSessionGroup)的标题来源是 extractMessageTextForRouting,那是个只认 text / post 的文本窥视,图片 / 文件 / 合并转发在那里一律返回 null → 占位名回落「新会话」、引言回落「(非文本消息)」、scheduleSessionGroupTitle({ userText: '' })

关键在于空串调度不是无害的 no-opscheduleSessionGroupTitle 的同步闸(in-flight / 重试预算)先把这次尝试登记掉,if (!userText.trim()) return; 才在异步体内生效,finally 里还会 markSessionGroupTitleFailed。于是这一次空调用白烧掉 3 轮有限重试中的一轮并挂上 30s 退避——这正是「转发消息集合开的群从不改名」的直接原因。

改了什么

① AI 命名挪到「消息完整解析之后」(非文本种子)

  • 出生侧只在窥视真拿到文本时才调度,并通过新的 RoutingContext.sessionGroupTitleScheduled 告诉下游「已经调过了」;
  • 非文本种子改由递归回来的 handleNewTopicAdmitted 调度——那时 parsed.content 已经是完整内容(合并转发已展开成 <forwarded_messages>、语音已转写),标题直接从转发进来的对话本身总结,比从空串猜强得多;
  • 两处严格二选一。不能指望 title 服务自己去重:它的 titled / in-flight 闸在异步体内,出生侧刚发起的那次此刻两者都还没置上,重复调用只会再白烧一轮。

② 把原私聊消息转发进新群当第一条消息

  • 新增 forwardMessage()im.v1.message.forward)。⚠️ 这个接口的 uuidparams 里而不是 data,与 create / reply 不同,已在 transport boundary 用例里按精确形状断言住;
  • 图片 / 文件 / 合并转发没法从事件 payload 复刻,只有转发能原样带过去(附件、转发树都在)。转发件由 bot 自己发出,回声是自发消息,dispatcher 对自发消息只放行 /close不会二次触发会话
  • 引言相应简化成一行「原消息已转发到本群(见上 ⬆️)」;转发被关掉或失败时回落到旧的正文摘录形态,文本种子不丢上下文;
  • 回复锚点回落链 intro → forwarded,保证始终指向群内消息(会话 rootMessageId / 首轮引用都取这个 id),两者都失败才退化到私聊;
  • 新增 sessionGroup.forwardOrigin 开关,默认开,置 false 即回到只发引言的旧形态。

影响面

  • 共用路径src/im/lark/client.ts新增 forwardMessage,未改任何既有原语;RoutingContext 只新增一个可选字段;SessionGroupConfig 新增一个可选 key(与既有 dmReceipt 同级,同样无需 dashboard / 文档联动)。
  • 会话类型:只影响 p2pMode='group'会话群出生这一条路径。普通话题会话、solo 群会话、adopt/restore 完全不经过这里;p2pMode 非 group 的私聊不受影响。
  • 文本种子行为变化:原消息现在也会被转发进群(引言从「正文摘录」变成「见上」)。这是刻意的——群的第一条消息始终是原件,来历一眼可见。
  • 跨 CLI / 跨后端 / 跨平台:不碰适配器、PTY、后端与任何平台相关代码。
  • 失败降级:转发失败只打 info 并回落旧引言;引言失败则用转发件当锚点;两者都失败时会话照样建在群里,只是回复退化到私聊。出生的额度扣减、ctx.messageId 仍指向原私聊消息(资源下载 / 合并转发子消息的 key 依赖它)等既有不变量均未改动。

测试验证

新增 test/session-group-birth-forward-seed.test.ts(8 例,跑真实的建群递归,只替身飞书外部副作用):

$ npx vitest run test/session-group-birth-forward-seed.test.ts
 ✓ test/session-group-birth-forward-seed.test.ts (8 tests) 1153ms

 Test Files  1 passed (1)
      Tests  8 passed (8)

覆盖:合并转发种子会被转发 + 引言不再是「(非文本消息)」;AI 命名恰好调度一次userText 是展开后的 <forwarded_messages>;占位名仍是「新会话」且 titled 未置;转发失败 → 引言回落且锚点走 intro;引言失败 → 锚点走转发件(不是私聊消息 id);文本种子对照组仍在出生侧调度一次;forwardOrigin: false 时不转发但命名照常。

相关既有套件 + 边界:

$ npx vitest run test/lark-transport-boundary.test.ts test/session-group-birth-workingdir.test.ts \
    test/session-group-birth-quota.test.ts test/session-group-birth-anchor.test.ts \
    test/session-groups-store.test.ts test/message-quota-enforcement.test.ts
 Test Files  6 passed (6)
      Tests  94 passed (94)

$ npx tsc --noEmit      # 无输出
$ bun run build         # 通过

全量:

$ bun run test
 Test Files  12 failed | 1300 passed | 1 skipped (1313)
      Tests  39 failed | 22727 passed | 44 skipped (22810)

这 12 个失败与本改动无关,已逐一证实:

  • 其中 4 个(daemon-pinned-working-dirgroup-join-shared-routingdoc-comment-audit-gatedoc-comment-daemon-concurrency)单独重跑全绿(4 passed / 69 tests passed),是全量并发下的资源争用抖动;
  • 另外 8 个是环境依赖,在同一 commit 的干净基线 worktreee636f93cb,未带本 PR 任何改动)上跑,失败文件与失败数逐条一致
基线 e636f93cb:  Test Files  8 failed (8)   Tests  37 failed | 92 passed (129)
  4 linux-isolation / 2 mojo-launcher-env-quarantine / 4 plugin-mcp-gateway /
  2 plugin-mcp-sandbox / 5 plugin-registry-sandbox-read / 1 sandbox-session-data-dir /
  1 session-store-sqlite-bun-import / 18 session-store-sqlite-poisoned-recovery

原因两类:① 本机 bun 是 1.4.0、仓库钉 1.4.2(用例里直接断言 expected '1.4.0' to be '1.4.2'),影响两个 sqlite 套件;② 其余依赖 user namespace / bwrap 等沙箱能力。

Live 验证未做(会切走全局 shim 影响所有 bot),本改动的外部副作用只有一次 im.v1.message.forward 调用,已在 transport boundary 用例里按精确请求形状断言。

🤖 Generated with Claude Code

私聊 group 模式下,用「合并转发消息集合 / 图片 / 文件」开的会话群会停在占位名
「新会话」,群里只留一条「(非文本消息)」,看不出这个群是怎么来的。

根因:出生流程的标题来源是 extractMessageTextForRouting 这个文本窥视,它只认
text/post,非文本种子拿到空串。而 scheduleSessionGroupTitle 的空串拦截在**异步体
内**——同步闸已经把这次尝试登记掉了,于是空调用白烧一轮有限重试(共 3 轮)并挂上
30s 退避,群就再也改不了名。

改动:
- 出生侧只在窥视真拿到文本时才调度 AI 命名,并用 RoutingContext.sessionGroupTitleScheduled
  告知下游;非文本种子改由递归回来的 handleNewTopic 在**消息完整解析之后**调度
  (合并转发已展开成 <forwarded_messages>、语音已转写),标题直接从转发内容里总结。
  两处严格二选一,避免重复调用再烧一轮重试。
- 新增 forwardMessage(im.v1.message.forward,注意 uuid 在 params 而非 data),
  出生时把原私聊消息转发进新群当第一条消息:图片/文件/合并转发没法从事件里复刻,
  只有转发能原样带过去。转发件由 bot 自己发出,回声是自发消息,dispatcher 只放行
  /close,不会二次触发会话。
- 引言相应简化为一行「原消息已转发到本群」;转发被关掉或失败时回落到旧的正文摘录形态。
- 回复锚点回落链 intro → forwarded,保证始终指向群内消息(会话 rootMessageId /
  首轮引用都取这个 id),两者都失败才退化到私聊。
- 新增 sessionGroup.forwardOrigin 开关(默认开)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner Author

感谢这个修复 —— 根因找得很准:出生侧 extractMessageTextForRouting 只认 text/post,而空串在命名服务里是进了 async 体才 returnsession-group-title.ts:172),闸门计数与 finallymarkSessionGroupTitleFailed 都已经执行,所以确实白烧一轮有限额度并布下退避。把命名推迟到「消息完整解析、合并转发已展开」之后,方向完全正确;forwardOrigin 的降级链(转发失败回落内联摘录、引言失败回落转发消息当锚点)也考虑得很周到。

自动评审跑下来有 1 个建议修改项,其余都是可选项。

建议修改:占位符会被当成标题源,且会把自愈路径永久锁死

新增的闸(daemon.ts:18285)只判 parsed.content.trim() 非空。但有几类消息解析出来只有占位符,它们非空却零信息:

  • 纯图片 → [图片 1]message-parser.ts:731
  • 纯文件(无文件名)→ [文件 1]
  • 合并转发展开失败或零子节点 → [合并转发消息]merge-forward.ts:155 的早退与 :159-161 的 catch 都会原样保留占位符)

占位符非空 ⟹ 命名服务不会 return ⟹ 真的起 CLI 跑完 ⟹ renameChat 成功 ⟹ markSessionGroupTitledtitled=true。之后两道闸同时关死:handleThreadReplyAdmitted 的自愈闸要求 !sgEntry.titleddaemon.ts:19956),命名服务自己也有 if (current.titled) return:163)⟹ 用户后面再发多少真实文本都改不回来

实测(驱动真实的建群递归,只替身外部飞书副作用):

IMAGE   calls=1 userText="[图片 1]"
MF_FAIL calls=1 userText="[合并转发消息]"     # 展开失败/零子节点

真机 claude -p 对这些 prompt 的产物:[图片 1] → 「图片内容分析请求」;[合并转发消息] → 「合并转发消息内容总结」。用假 CLI 端到端验证锁死:renames=["图片"] titledAfterFirst=true finalTitled=true

值得留意的是与修复前相比的方向:master 上这条路径喂的是空串 ⟹ 命名服务在 async 体内 return ⟹ 永远不会置 titled ⟹ 群停在「新会话」不好看,但自愈闸还开着,用户下一条文本就能治好。改成喂占位符之后,群被钉死成一个无意义标题且不可逆。所以这一项的代价是从「暂时难看、可自愈」变成「永久错名、不可逆」,比修复前更糟一些 —— 这是把它列为建议修改而不是可选项的原因。

另外 [合并转发消息] 这条正好落在本 PR 的主目标场景上:合并转发开的群,只要展开那一步失败(拉子消息超时/权限),就会被钉死。

建议改法(已实测可行)

在那个闸上加一条「只剩零信息占位符就不调度」,把命名留给后续真实文本自愈:

const titleSeed = parsed.content.trim();
// 只剥「零信息」占位符(渲染处见 message-parser 的 imgLabel / 文件分支 /
// AUDIO_PLACEHOLDER / merge_forward 兜底)。带真实文件名或图片 alt 的
// `[文件 1: x.pdf]` / `[图片 2: 图说]` 刻意保留——它们是有效的标题来源。
const seedIsOnlyPlaceholders =
  !titleSeed.replace(/\[(?:|||)(?:\s+\d+)?\]/g, '').trim();
if (sgEntry && !sgEntry.titled && titleSeed && !titleSeed.startsWith('/') && !seedIsOnlyPlaceholders) {
  scheduleSessionGroupTitle({ larkAppId, chatId, userText: parsed.content });
}

在真实建群递归下实测五种种子:

种子 行为
纯图片 [图片 1] 不调度(留给自愈)✅
文件带名 [文件 1: 季度汇报.pdf] 照常调度 ✅
合并转发展开成功 照常调度真 XML 内容 ✅
合并转发展开失败 不调度(留给自愈)✅
文本种子 出生侧调度一次 ✅

PR 自有的 4 个套件 26/26 仍全绿,tsc --noEmit 0 错,bun run build 绿。

带真实文件名的 [文件 1: 季度汇报.pdf] 和带 alt 的 [图片 2: 图说]message-parser.ts:1400 的 alt 折叠,:] 所以不会被正则命中)都刻意保留 —— 它们确实是有信息量的标题源。

两个配套建议

  1. 补测试session-group-birth-forward-seed.test.ts 目前覆盖了合并转发与文本种子,但图片种子的命名路径没有覆盖 —— 这正是上面这一项能漏过去的原因。建议补三条:图片不调度 / 文件带名调度 / 合并转发展开失败不调度。
  2. 注释:正则与 message-parser 的占位符字面量是隐式耦合的(那边把 [图片] 改成别的,这边会静默失效),建议在正则处留一句注释指向渲染处。更彻底的做法是让 parser 导出一个 isPlaceholderOnly()AUDIO_PLACEHOLDER 已经导出,但图片占位符目前是 imgLabel 函数内联生成、没有常量),不过那超出本 PR 范围,留作后续即可。

可选项(不影响合入)

  • forwardMessage 不发 outbound hook,注释里说「与 sendUserMessage 同」属实;只是转发的是用户内容进群,做审计/中继的下游可能预期能看到。属存量口径问题。
  • sessionGroup.forwardOrigin 没有文档条目 —— 与既有的 dmReceipt 一致,符合先例,顺手补上更好。

其它已核验

  • 基线:在最新 origin/master 上 rebase 零冲突,净编辑与原 patch 逐字相同。
  • im.v1.message.forwarduuid 确实在 params 而非 data(与 create/reply 不同),SDK 类型定义已核对,注释写对了。
  • 转发进群的副本由 bot 发出,其回声在 dispatcher 处只放行 /close,不会自激触发新一轮 —— 这一点注释里的说明成立。
  • CI 13/13 全绿;mergeStateStatus=BLOCKED 与本 PR 质量无关(作者与 CODEOWNERS 同一身份,无法自审通过)。
  • 变异测试 4 处,每处只让对应用例转红,说明新增测试确实有效。

以上是自动评审的初步意见,可能有理解偏差,最终以维护者审阅为准

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant